Skip to content

Add multi-device recording controls - #160

Open
kyleve wants to merge 39 commits into
mainfrom
codex/multi-device-recording
Open

Add multi-device recording controls#160
kyleve wants to merge 39 commits into
mainfrom
codex/multi-device-recording

Conversation

@kyleve

@kyleve kyleve commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Posted by an AI agent on kyleve's behalf.

Summary

  • keep automatic-recording consent local to each installation instead of syncing a global recorder assignment
  • default iPhone onboarding to On only when no other active device reported recording or permission-required in the previous 24 hours; default iPad, other devices, and explicit rejoins to Off
  • sync immutable device profiles, nickname changes, advisory status check-ins, and append-only removal tombstones through CloudKit
  • let every installation review and correct history or upload evidence, while only the current installation can change its own recording choice
  • add a global Remove from Where action that stops the target when it next syncs and hides its automatic samples at and after the earliest removal timestamp
  • preserve earlier history, local consent, and existing removals through backup and reset flows
  • give a removed installation a blocking rejoin flow that creates a fresh identity and asks for recording consent again, defaulted Off

Why

A device left at home can record a stationary location that contradicts the user's actual travel. The first implementation solved that with an account-wide, append-only assignment DAG that selected Off or exactly one recorder. That was technically strong, but it made ordinary consent, CloudKit merging, backup restore, transfer, acknowledgement, and conflict UX much more complicated than the product needs.

This version keeps the useful 99%: each installation owns its own recording consent, onboarding makes a conservative recommendation, other devices expose read-only status, and a user can still permanently remove a lost, sold, or retired identity from anywhere. Apple Lost Mode or remote erase remains the security boundary for a stolen device.

Product behavior

  • Onboarding: phones recommend On unless another non-removed device checked in as Recording or Permission Required during the last 24 hours; tablets and other hardware recommend Off. The user always verifies the toggle before continuing, and a changed choice on a failed onboarding retry is persisted before the next scope opens.
  • Devices settings: the current device has the only editable Automatic Recording toggle. Other devices show their last advisory status and can be renamed or removed.
  • Local choice: changing the toggle persists in the backup-excluded installation sidecar together with the current On interval's enable timestamp. CloudKit refreshes update presentation state but can never manufacture a local toggle command, and a relaunch cannot admit a late visit from an earlier Off interval.
  • Removal: a tombstone is permanent, account-synced, and global across data epochs. The target stops and discards pending automatic fixes when it observes the tombstone. User-facing history excludes that identity's automatic samples at and after the earliest removal date; earlier, legacy, manual, and evidence-derived history remains visible.
  • Reset: account reset retires every causally pre-reset installation, including a profile that was offline and reached CloudKit only after the resetter took its snapshot.
  • Rejoin: a removed current installation shows a blocking recovery screen. Rejoin retires its old runtime, creates a new identity, returns through recording onboarding, and recommends Off.
  • Two fresh phones: simultaneous first-run installs can both recommend On before either check-in arrives. This is an accepted advisory race; the Devices screen makes both statuses visible and each phone can be turned off locally.

Architecture and safety

  • DeviceRecordingController serializes this installation's local choice, physical GPS transition, profile registration, advisory check-in, removal observation, and failed-Off cleanup barrier.
  • Physical recording changes complete before a Recording check-in is published. Any store/read failure revokes GPS and exposes an unavailable state; a failed Off cleanup prevents re-enabling until the retry backlog is successfully discarded.
  • RecordingDeviceRemoval is an immutable global tombstone. Unreadable rows invalidate the removal read; identical CloudKit duplicates collapse across epochs, while conflicting same-ID payloads fail closed.
  • WhereDataEpoch.resetBarrier uses the causal epoch graph to identify installations registered before an observed account reset, including concurrent-reset synthetic frontiers.
  • InstallationRecordingContext.RecordingChoice makes unconfirmed, Off, and On-with-cutoff mutually exclusive persisted states.
  • LocationHistoryReader is the shared removal-aware boundary used by reports, widgets, recent activity, issue scans, and foreground duplicate checks.
  • LocationOutbox now stores complete bounded queue snapshots in JournalKit: the newest intact snapshot wins after a torn tail, the previous JSON sidecar migrates once, and destructive clears durably checkpoint empty before deleting raw bytes. Entries remain data-epoch-pinned, so reset, Replace, or cleanup races cannot replay fixes into another generation. If both the primary store and outbox write fail, ingestion retains the sample in memory and revokes recording authority rather than pretending it is durable.
  • persistent-history transaction authors still distinguish local saves from external imports before remote changes are reconciled.
  • the assignment DAG, authority snapshot/frontier, remote toggle commands, acknowledgement UX, and assignment-based sample filter are removed.

Backup and compatibility

  • backup format v3 includes device profiles, nickname history, removal tombstones, and sample provenance; it contains no local recording consent or advisory check-ins
  • Merge imports restorable device/removal history without changing the current installation's local choice
  • Replace rotates the data epoch, preserves every existing removal tombstone, imports the archive, and restores the same local choice after commit
  • Where/Tools/upgrade-backup.rb upgrades shipped v1/v2 archives to v3 without inventing an installation or recording consent
  • the unshipped assignment format was removed rather than retained as compatibility baggage

Validation

  • ./test --all: 1,774 unit/integration tests passed
  • backup upgrader: 5 tests / 11 assertions
  • ./swiftformat --lint
  • ./xcstrings --lint
  • swift run bumper config .
  • swift run bumper test . (15 architecture tests)
  • swift run bumper lint . --timings
  • git diff --check

Rollout

Before shipping, deploy the additive CloudKit schema to Production and complete the two-device validation checklist in Where/Where/README.md. Use ./Where/install --cloudkit for that validation; an ordinary Debug install intentionally remains local-only.

@kyleve
kyleve force-pushed the codex/multi-device-recording branch from 1bcb8d1 to 01a9311 Compare July 30, 2026 23:08
@kyleve
kyleve marked this pull request as ready for review July 30, 2026 23:13
Comment thread Where/Where/README.md
CloudKit mirroring. Debug uses `.localOnly`; exercise sync with a Release-signed
build.

Before shipping a schema change:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should like part of a validation test script or tool that we should perhaps automate in some way.

We recently added support via the Inspector module for booting the app into different modes, maybe we add some sort of iCloud inspect/test mode to that?

Comment thread Where/WhereCore/Sources/Devices/DeviceRecordingController.swift
Comment thread Where/WhereCore/Sources/Devices/RecordingDevice.swift
Comment thread Where/WhereCore/Sources/Persistence/SwiftDataStore.swift Outdated
Comment thread Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift Outdated
Comment thread Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift Outdated
Comment thread Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift Outdated
Comment thread Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift Outdated
Comment thread Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift Outdated
Comment thread Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift Outdated
Comment thread Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift Outdated
Comment thread Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift Outdated
.padding(.bottom, stylesheet.spacing.xxxLarge)
}

private var recordingTitle: LocalizedStringResource {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still have (even, opt-in) access to the device name? If so I think we should request it instead of just doing this fallback.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additionally, recording the device name alongside the events, even for single event users is useful for audits should they happen; for non-nefarious users, it shows what device the event came from.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: We'll do this later.

kyleve added 2 commits August 4, 2026 11:43
…cording

# Conflicts:
#	Where/TODOs.md
#	Where/WhereUI/Tests/WhereSessionTrackingTests.swift
Replace the proposed coalescing-worker model with the shipped generation-token and exclusive-controller-lane design. Exercise authorized, denied, repeated, and reversed commands; retain the old race as a negative control; and prove the stale-permission branch is reachable.
@kyleve

kyleve commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Posted by an AI agent on kyleve’s behalf.

I updated the Tracking Reconciliation TLA+ pilot in 27bf4efd so it models the design this PR actually ships: the immediate installation-sidecar choice, the generation check after an enable permission suspension, and the FIFO exclusive transition lane in DeviceRecordingController.

Verdict: Verified for these model bounds and assumptions.

TLC results:

  • Negative control reproduced the old stale-publication race: 33 generated / 26 distinct states, depth 8.
  • The current model demonstrably reaches stale permission rejection: 5 / 5 states, depth 4.
  • Authorized enable → disable: 22 / 16 states, depth 8.
  • Unauthorized enable → disable: 22 / 16 states, depth 8.
  • Repeated enable → enable → disable: 96 / 56 states, depth 12.
  • Reversed disable → enable: 17 / 12 states, depth 8.
  • Every current-design configuration completed without an invariant, temporal-property, or deadlock error.

The deterministic Swift guard, WhereSessionTrackingTests.offWinsWhileAnEarlierEnableWaitsForPermission, also passes against the real permission suspension and presentation state.

The model uses TLC 1.7.4 / TLC2 2.19 with Temurin 21.0.8+9. It assumes a finite command list, eventual permission completion, and eventual completion of an admitted Core transition. It deliberately excludes reset/import lifecycle operations, device removal, persistence failure, cancellation, process termination, GPS samples, and unbounded command streams; those are not covered by this result.

The source mapping, properties, configurations, assumptions, and exclusions are documented in Where/Specifications/TrackingReconciliation/README.md.


/// The throwaway identity used by demo mode. It is intentionally distinct
/// from test fixtures and never belongs to the real installation sidecar.
public static let demo = InstallationRecordingContext(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if we should @_spi(Demo) code? Might be worth it

/// Bridge this main-actor sidecar to the coordinator's async persistence seam without
/// exposing the adapter's filesystem details to Core.
public var backupImportRecoveryPersistence: BackupCoordinator.ImportRecoveryPersistence {
BackupCoordinator.ImportRecoveryPersistence(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this being a struct we "fill in" vs being a protocol remains weird

/// therefore mirror physical GPS state without observing every unrelated store transaction or
/// independently re-running the domain reconciliation policy.
final class RecordingConfigurationBroadcaster: @unchecked Sendable {
private let lock = NSLock()

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the actual modern swift way to do this?

public enum RecordingDeviceKind: String, Codable, Sendable, Hashable {
case phone
case tablet
case other

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add an optional associated string to this?

///
/// Recording consent deliberately does not live here; it stays installation-local while
/// irreversible removal tombstones sync separately.
public struct RecordingDeviceMetadataChange: Identifiable, Codable, Sendable, Hashable {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this work when we have more editable fields on a device? Could we change this to some sort of key path based edit instead? Or maybe nickname becomes an enum of the edit kind? Or would we expect to add more similar types (seems bad)?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(The enum route seems to make the most sense to me, and I think maintains the codable state?)

return lhs.id.uuidString < rhs.id.uuidString
}

/// Stable winner when CloudKit supplies conflicting values for one immutable event id.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"for one immutable event id" this bears a lot of weight; is the ID a UUID, a monotomically increasing int, something else?

_ lhs: RecordingDeviceMetadataChange,
_ rhs: RecordingDeviceMetadataChange,
) -> Bool {
if lhs.deviceID != rhs.deviceID {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this check should be first? It feels essentially not deterministic

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Unless this is a true fallback tiebreaker?)


/// Irreversible, append-only tombstone retiring one installation identity.
public struct RecordingDeviceRemoval: Identifiable, Codable, Sendable, Hashable {
public let id: UUID

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as similar comments; can we push this to a typed type?


public func addEvidence(_ evidence: Evidence, blob: Data? = nil) async throws {
try await store.perform { try await store.write(evidence: evidence, blob: blob) }
let epochID = try await (store.dataEpoch()).id

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh yeah we gotta add a helper for this, its in so many places

self,
selector: #selector(eventChanged(_:)),
name: NSPersistentCloudKitContainer.eventChangedNotification,
object: nil,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be restricting this to a given store or something?

self,
selector: #selector(persistentStoreDidChange(_:)),
name: .NSPersistentStoreRemoteChange,
object: nil,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to above, should we not restrict this to one store?

/// Dedicated read context and epoch active for one multi-table snapshot. Reads inside the
/// snapshot use this pair even if CloudKit imports a newer generation mid-block; the block
/// then fails its end validation rather than returning mixed-generation state.
private var snapshotContext: ModelContext?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Putting a pin in this one for myself, look up what this is

}
return writerContext
}
if Self.activeSnapshotStores.contains(storeID) {

@kyleve kyleve Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this static reference is setting off my spidey sense; how could we model this without a static reference?

}

public func dataEpoch() async throws -> WhereDataEpoch {
if Self.activeTransactionStores.contains(ObjectIdentifier(self)), let writerEpoch {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah this is in a lot of places huh? I don't like it!

id: UUID,
installationID: RecordingDeviceID,
) async throws {
let context = mutationContext()

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Random, but I have no idea how this actually gets saved given we don't do it inline here

if $0.registeredAt != $1.registeredAt {
return $0.registeredAt < $1.registeredAt
}
if $0.systemName != $1.systemName { return $0.systemName < $1.systemName }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What exactly is systemName?

}
}

public func recordingDeviceMetadataChanges() async throws -> [RecordingDeviceMetadataChange] {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When would a consuming framework call this? It's public but it takes no parameters

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see it returns something but it still feels weird, but I'm likely wrong!

)
let active = existing.filter { Self.belongs($0.epochID, to: epochID) }
guard !active.isEmpty else {
context.insert(SDRecordingDeviceMetadataChange(value: change, epochID: epochID))

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should allow / change to initializing this type from RecordingDeviceMetadataChange directly so we dont need to pass through every value at every calcite

}),
)
for record in samples {
for record in samples where Self.belongs(record.epochID, to: epochID) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this feels like a bit of a code smell to me honestly; what if we pushed all these epoch-driven-checks into a sub-object?

let descriptor = FetchDescriptor<SDDismissedIssue>(predicate: #Predicate { $0.key == key })
if let record = try context.fetch(descriptor).first {
if let record = try context.fetch(descriptor).first(where: {
Self.belongs($0.epochID, to: epochID)

@kyleve kyleve Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this feels like a patch; if epochs are part of the model now, let's add it to the query vs filtering on it?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Again reminder, we've not shipped yet, we can change things!)


public func trackedRegions() async throws -> Set<Region> {
let context = readContext()
let epochID = try readEpochID(in: context)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's gotta be a better way to model this than checking the epoch at every call right?


public func primaryRegions() async throws -> [PrimaryRegion] {
let context = readContext()
let epochID = try readEpochID(in: context)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this is pervasive, there has to be a better way to represent this


@Model
final class SDEvidence {
var epochID: UUID?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again similar comment, should be a typed type


@Model
final class SDManualDay {
var epochID: UUID?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment, use a typed epoch id


@Model
final class SDDismissedIssue {
var epochID: UUID?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep we gotta change this out; each time I see it, feels weird as bare UUID

/// new epoch before writing their result, so records uploaded later by an offline device remain
/// in the superseded epoch and cannot repopulate or alter the new account state.
public struct WhereDataEpochID: RawRepresentable, Codable, Sendable, Hashable {
public let rawValue: UUID

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow we're back, got here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant